/*
 *************************************************************************
 * Copyright (c) 2013 Actuate Corporation.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * https://www.eclipse.org/legal/epl-2.0/.
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 *
 * Contributors:
 *  Actuate Corporation - initial API and implementation
 *
 *************************************************************************
 */

package org.eclipse.birt.data.oda.mongodb.impl;

import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Time;
import java.sql.Timestamp;
import java.util.Base64;
import java.util.Iterator;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.bson.Document;
import org.bson.types.BSONTimestamp;
import org.eclipse.birt.data.oda.mongodb.internal.impl.DriverUtil;
import org.eclipse.birt.data.oda.mongodb.internal.impl.MDbMetaData.FieldMetaData;
import org.eclipse.birt.data.oda.mongodb.internal.impl.QueryProperties;
import org.eclipse.birt.data.oda.mongodb.internal.impl.ResultDataHandler;
import org.eclipse.birt.data.oda.mongodb.nls.Messages;
import org.eclipse.datatools.connectivity.oda.IBlob;
import org.eclipse.datatools.connectivity.oda.IClob;
import org.eclipse.datatools.connectivity.oda.IResultSet;
import org.eclipse.datatools.connectivity.oda.IResultSetMetaData;
import org.eclipse.datatools.connectivity.oda.OdaException;
import org.eclipse.datatools.connectivity.oda.impl.Blob;

import com.mongodb.BasicDBList;
import com.mongodb.DBCursor;

/**
 * Implementation class of IResultSet for the MongoDB ODA runtime driver.
 */
@SuppressWarnings("deprecation")
public class MDbResultSet implements IResultSet {
	private Iterator<Document> m_resultsIterator;
	private DBCursor m_mongoCursor;
	private MDbResultSetMetaData m_metadata;
	private QueryProperties m_queryProps;

	private Document m_currentRow;
	private int m_currentRowId = 0; // 1-based index
	private int m_maxRows = 0; // no limit by default
	private boolean m_wasNull = true;
	private ResultDataHandler m_dataHandler;

	private static Logger sm_logger = DriverUtil.getLogger();

	public MDbResultSet(Iterator<Document> resultsIterator, MDbResultSetMetaData rsmd, QueryProperties queryProps) {
		if (resultsIterator == null || rsmd == null) {
			throw new IllegalArgumentException("null DBCursor"); //$NON-NLS-1$
		}

		m_resultsIterator = resultsIterator;
		// if( resultsIterator instanceof DBCursor )
		// m_mongoCursor = (DBCursor)resultsIterator;
		m_metadata = rsmd;
		m_queryProps = queryProps != null ? queryProps : QueryProperties.defaultValues();
		if (projectsFlattenedRows()) {
			m_dataHandler = new ResultDataHandler(m_metadata);
		}
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getMetaData()
	 */
	@Override
	public IResultSetMetaData getMetaData() throws OdaException {
		return m_metadata;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#setMaxRows(int)
	 */
	@Override
	public void setMaxRows(int max) throws OdaException {
		m_maxRows = max;
		if (m_maxRows > 0 && m_mongoCursor != null) {
			m_mongoCursor.limit(m_maxRows);
		}
	}

	/**
	 * Returns the maximum number of rows that can be fetched from this result set.
	 *
	 * @return the maximum number of rows to fetch.
	 */
	protected int getMaxRows() {
		return m_maxRows >= 0 ? m_maxRows : 0; // negative value is ignored
	}

	private boolean hasNoMaxLimit() {
		return m_maxRows <= 0;
	}

	private boolean projectsFlattenedRows() {
		return m_queryProps.isAutoFlattening();
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#next()
	 */
	@Override
	public boolean next() throws OdaException {
		// handle automatic flattening of embedded objects - default is false
		if (projectsFlattenedRows()) {
			if (m_dataHandler != null && m_dataHandler.next()) {
				return true;
			}
		}

		// get next row from the iterator
		m_currentRow = null; // reset
		if (m_resultsIterator == null || !m_resultsIterator.hasNext()) {
			return false;
		}

		// has next row; check the maximum rows limit
		if (hasNoMaxLimit() || m_currentRowId < getMaxRows()) {
			m_currentRow = m_resultsIterator.next();
			m_currentRowId++;
			return true;
		}

		return false;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#close()
	 */
	@Override
	public void close() throws OdaException {
		m_currentRow = null;
		m_currentRowId = 0; // reset row counter
		m_resultsIterator = null;
		if (m_mongoCursor != null) {
			m_mongoCursor.close();
			m_mongoCursor = null;
		}
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getRow()
	 */
	@Override
	public int getRow() throws OdaException {
		return m_currentRowId;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getString(int)
	 */
	@Override
	public String getString(int index) throws OdaException {
		return getString(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getString(java.lang.String)
	 */
	@Override
	public String getString(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		if (columnValue instanceof String) {
			return (String) columnValue;
		}

		if (columnValue instanceof List && !(columnValue instanceof BasicDBList)) {
			// convert generic List to JSON-formattable list
			List<?> fromList = (List<?>) columnValue;
			if (!fromList.isEmpty()) {
				BasicDBList fieldValuesList = new BasicDBList();
				for (int index = 0; index < fromList.size(); index++) {
					fieldValuesList.put(index, fromList.get(index));
				}
				fieldValuesList.markAsPartialObject();
				return fieldValuesList.toString(); // return JSON expr format
			}
		}

		if (columnValue instanceof byte[]) {
			return convertToString((byte[]) columnValue);
		}

		return columnValue != null ? columnValue.toString() : null;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getInt(int)
	 */
	@Override
	public int getInt(int index) throws OdaException {
		return getInt(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getInt(java.lang.String)
	 */
	@Override
	public int getInt(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, Integer.class);

		if (columnValue instanceof List) {
			columnValue = getFirstFieldValue((List<?>) columnValue, Integer.class, columnName);
		}
		if (columnValue instanceof Integer) {
			return (Integer) columnValue;
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "integer" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		// null value
		return 0;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getDouble(int)
	 */
	@Override
	public double getDouble(int index) throws OdaException {
		return getDouble(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getDouble(java.lang.String)
	 */
	@Override
	public double getDouble(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, Double.class);

		if (columnValue instanceof List) {
			columnValue = getFirstFieldValue((List<?>) columnValue, Double.class, columnName);
		}
		if (columnValue instanceof Double) {
			return (Double) columnValue;
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "double" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		// null value
		return 0d;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getBigDecimal(int)
	 */
	@Override
	public BigDecimal getBigDecimal(int index) throws OdaException {
		return getBigDecimal(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getBigDecimal(java.lang.
	 * String)
	 */
	@Override
	public BigDecimal getBigDecimal(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, BigDecimal.class);
		if (columnValue instanceof BigDecimal) {
			return (BigDecimal) columnValue;
		}

		if (columnValue instanceof List) {
			return (BigDecimal) getFirstFieldValue((List<?>) columnValue, BigDecimal.class, columnName);
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "BigDecimal" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		return null;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getDate(int)
	 */
	@Override
	public Date getDate(int index) throws OdaException {
		return getDate(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getDate(java.lang.String)
	 */
	@Override
	public Date getDate(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, Date.class);
		if (columnValue instanceof Date) {
			return (Date) columnValue;
		}

		if (columnValue instanceof List) {
			return (Date) getFirstFieldValue((List<?>) columnValue, Date.class, columnName);
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "Date" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		return null;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getTime(int)
	 */
	@Override
	public Time getTime(int index) throws OdaException {
		return getTime(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getTime(java.lang.String)
	 */
	@Override
	public Time getTime(String columnName) throws OdaException {
		Date dateValue = getDate(columnName);
		if (dateValue == null) {
			return null;
		}
		return new Time(dateValue.getTime());
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getTimestamp(int)
	 */
	@Override
	public Timestamp getTimestamp(int index) throws OdaException {
		return getTimestamp(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getTimestamp(java.lang.
	 * String)
	 */
	@Override
	public Timestamp getTimestamp(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, Timestamp.class);

		if (columnValue instanceof List) {
			columnValue = getFirstFieldValue((List<?>) columnValue, Timestamp.class, columnName);
		}
		if (columnValue instanceof Timestamp) {
			return (Timestamp) columnValue;
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "Timestamp" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		return null;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getBlob(int)
	 */
	@Override
	public IBlob getBlob(int index) throws OdaException {
		return getBlob(findFieldName(index));
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getBlob(java.lang.String)
	 */
	@Override
	public IBlob getBlob(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, byte[].class);

		if (columnValue instanceof List) {
			columnValue = getFirstFieldValue((List<?>) columnValue, byte[].class, columnName);
		}
		if (columnValue instanceof byte[]) {
			return new Blob((byte[]) columnValue);
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "Blob" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		return null;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getClob(int)
	 */
	@Override
	public IClob getClob(int index) throws OdaException {
		throw MDbQuery.sm_unSupportedOpEx;
	}

	/*
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getClob(java.lang.String)
	 */
	@Override
	public IClob getClob(String columnName) throws OdaException {
		throw MDbQuery.sm_unSupportedOpEx;
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getBoolean(int)
	 */
	@Override
	public boolean getBoolean(int index) throws OdaException {
		return getBoolean(findFieldName(index));
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getBoolean(java.lang.
	 * String)
	 */
	@Override
	public boolean getBoolean(String columnName) throws OdaException {
		Object columnValue = getFieldValue(columnName);
		columnValue = tryConvertToDataType(columnValue, Boolean.class);

		if (columnValue instanceof List) {
			columnValue = getFirstFieldValue((List<?>) columnValue, Boolean.class, columnName);
		}
		if (columnValue instanceof Boolean) {
			return (Boolean) columnValue;
		}

		// not convertible
		if (columnValue != null) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData,
					new Object[] { columnName, columnValue, columnValue.getClass().getSimpleName(), "boolean" }); //$NON-NLS-1$
			getLogger().severe(errMsg);
			throw new OdaException(errMsg);
		}

		// null value
		return false;
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#getObject(int)
	 */
	@Override
	public Object getObject(int index) throws OdaException {
		return getObject(findFieldName(index));
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see
	 * org.eclipse.datatools.connectivity.oda.IResultSet#getObject(java.lang.String)
	 */
	@Override
	public Object getObject(String columnName) throws OdaException {
		return getFieldValue(columnName);
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#wasNull()
	 */
	@Override
	public boolean wasNull() throws OdaException {
		return m_wasNull;
	}

	/*
	 * @see org.eclipse.datatools.connectivity.oda.IResultSet#findColumn(java.lang.
	 * String)
	 */
	@Override
	public int findColumn(String columnName) throws OdaException {
		return m_metadata.getColumnNumber(columnName);
	}

	private String findFieldName(int columnNumber) throws OdaException {
		return getMetaData().getColumnName(columnNumber);
	}

	private Object getFieldValue(String columnName) throws OdaException {
		Object fieldValue = doGetFieldValue(columnName);
		m_wasNull = (fieldValue == null);
		return fieldValue;
	}

	private Object doGetFieldValue(String columnName) throws OdaException {
		if (!projectsFlattenedRows()) {
			FieldMetaData fieldMD = m_metadata.getColumnMetaData(columnName);
			return ResultDataHandler.fetchFieldValues(columnName, fieldMD, m_currentRow);
		}

		// flatten array of nested documents' field values, if exists, and projects into
		// multiple result rows
		return m_dataHandler.getFieldValue(columnName, m_currentRow);
	}

	/*
	 * Get specified type of value from the first element in an array; handles
	 * nested arrays. Returns a null value if not able to return value in the
	 * specified valueDataType.
	 */
	private Object getFirstFieldValue(List<?> valuesList, Class<?> valueDataType, String logColumnName)
			throws OdaException {
		Object value = getFirstElementFromList(valuesList, logColumnName);
		value = tryConvertToDataType(value, valueDataType);
		if (valueDataType.isInstance(value)) {
			return value;
		}
		if (value instanceof List) { // nested array
			return getFirstFieldValue((List<?>) value, valueDataType, logColumnName);
		}

		// not convertible
		if (value != null && getLogger().isLoggable(Level.FINE)) {
			getLogger().fine(Messages.bind(
					"Unable to get the '{0}' field's first array value ({1}) in {2} data type as a {3} value.", //$NON-NLS-1$
					new Object[] { logColumnName, value, value.getClass().getSimpleName(),
							valueDataType.getSimpleName() }));
		}

		m_wasNull = true;
		return null;
	}

	private static Object getFirstElementFromList(List<?> valuesList, String columnName) {
		if (valuesList.size() == 0) {
			return null;
		}
		Object firstValue = null;
		if (valuesList instanceof BasicDBList) {
			firstValue = ((BasicDBList) valuesList).get(String.valueOf(0));
		} else {
			firstValue = valuesList.get(0);
		}

		// log that only first value in array is returned
		logFetchedFirstElementFromArray(columnName, valuesList.size());
		return firstValue;
	}

	private static Object tryConvertToDataType(Object value, Class<?> toDataType) throws OdaException {
		if (value == null || toDataType.isInstance(value)) { // already in specified data type
			return value;
		}

		try {
			if (value instanceof String) {
				String stringValue = (String) value;
				if (toDataType == Integer.class) {
					return Integer.valueOf(stringValue);
				}
				if (toDataType == Double.class) {
					return Double.valueOf(stringValue);
				}
				if (toDataType == BigDecimal.class) {
					return new BigDecimal(stringValue);
				}
				if (toDataType == Boolean.class) {
					return Boolean.valueOf(stringValue);
				}
				if (toDataType == Date.class) {
					return Date.valueOf(stringValue);
				}
				if (toDataType == Timestamp.class) {
					return Timestamp.valueOf(stringValue);
				}
				if (toDataType == byte[].class) {
					return tryConvertToBytes(stringValue);
				}
			}

			if (value instanceof java.util.Date) // the object type returned by MongoDB for a Date field
			{
				long msTime = ((java.util.Date) value).getTime();
				if (toDataType == Date.class) {
					return new Date(msTime);
				}
				if (toDataType == Timestamp.class) {
					return new Timestamp(msTime);
				}
			}

			if (value instanceof BSONTimestamp) {
				long msTime = ((BSONTimestamp) value).getTime() * 1000L;
				if (toDataType == Date.class) {
					return new Date(msTime);
				}
				if (toDataType == Timestamp.class) {
					return new Timestamp(msTime);
				}
			}

			if (toDataType == Integer.class) {
				return tryConvertToInteger(value);
			}
			if (toDataType == Double.class) {
				return tryConvertToDouble(value);
			}
			if (toDataType == Boolean.class) {
				return tryConvertToBoolean(value);
			}
		} catch (Exception ex) {
			String errMsg = Messages.bind(Messages.mDbResultSet_cannotConvertFieldData, new Object[] {
					DriverUtil.EMPTY_STRING, value, value.getClass().getSimpleName(), toDataType.getSimpleName() });
			getLogger().severe(errMsg);

			OdaException odaEx = new OdaException(errMsg);
			odaEx.initCause(ex);
			throw odaEx;
		}

		// non-handled data type conversion; return value as is
		return value;
	}

	private static Object tryConvertToInteger(Object value) {
		if (value instanceof Number) {
			return Integer.valueOf(((Number) value).intValue());
		}
		if (value instanceof Boolean) {
			return ((Boolean) value) ? Integer.valueOf(1) : Integer.valueOf(0);
		}
		return value; // not able to convert; return value as is
	}

	private static Object tryConvertToDouble(Object value) {
		if (value instanceof Number) {
			return Double.valueOf(((Number) value).doubleValue());
		}
		if (value instanceof Boolean) {
			return ((Boolean) value) ? Double.valueOf(1d) : Double.valueOf(0d);
		}
		return value; // not able to convert; return value as is
	}

	private static Object tryConvertToBoolean(Object value) {
		if (value instanceof Number) {
			return ((Number) value).doubleValue() != 0 ? Boolean.TRUE : Boolean.FALSE;
		}
		return value; // not able to convert; return value as is
	}

	private static Object tryConvertToBytes(String stringValue) {
		try {
			return Base64.getDecoder().decode(stringValue);
		} catch (Exception ex) {
			// DatatypeConverter could be un-initialized,
			// log and continue; note that Base64Codec#decode might be unavailable in some
			// versions
			if (getLogger().isLoggable(Level.FINE)) {
				getLogger().fine(
						Messages.bind("Unable to convert the String field value ({0}) to a bytes[] value.\n Cause: {1}", //$NON-NLS-1$
								stringValue, ex.getMessage()));
			}
		}
		return stringValue; // not able to convert; return value as is
	}

	private static String convertToString(byte[] value) {
		return Base64.getEncoder().encodeToString(value);
	}

	private static void logFetchedFirstElementFromArray(String columnName, int arraySize) {
		// log that only first value in set is returned
		if (arraySize > 1 && getLogger().isLoggable(Level.FINER)) {
			getLogger().finer(Messages.bind("Fetching only the first value out of {0} for the field {1}.", //$NON-NLS-1$
					Integer.valueOf(arraySize), columnName));
		}
	}

	private static Logger getLogger() {
		return sm_logger;
	}

}
